2026-09-08 晚上 22:49,我坐在電腦前查自己的兩條排程,只想知道一件很簡單的事:你們今天到底有沒有做事?
一條是每日情報,抓當天的新訊息寫成一份 Markdown。腳本在,註解寫著每天 17:03 執行,但我查的時候,找不到對應的系統服務。情報檔累積了 53 份。
另一條是影片輪詢,定時去問來源清單有沒有新影片。服務有在系統清單裡,最後一次退出狀態是 0。候選數紀錄有 97 條,其中 88 條是 0。
看起來資訊很多,其實我什麼都沒問到。
53 份情報和 97 條紀錄都只是存量,不是「跑了幾次」。中間有些是我手動跑的,日期還缺了六天。而那個退出狀態 0 更不能讓我安心,它只說腳本正常收尾,沒說那一輪抓到東西。
所以我想知道的那題,兩份紀錄都答不出來:這一輪,到底有沒有新資料要處理?
我把影片輪詢腳本裡所有正常退出的位置全部翻出來,一共七處,整理起來是六類:
| 退出前發生什麼 | 實際意思 |
|---|---|
| 設定檔或來源網址缺失 | 這輪連開始的條件都不夠 |
| 必要指令缺失 | 執行環境不完整 |
| 單實例鎖被別人持有 | 另一輪可能在跑,這輪略過 |
| 來源清單抓取失敗 | 向來源要資料失敗了 |
| 待處理候選為零 | 來源有回,只是現在沒工作 |
| 試跑模式 | 只列候選,不做後續處理 |
六類,全部 exit 0。
「缺設定」跟「正常沒有新影片」,在我自己寫的腳本裡長得一模一樣。當初我一定覺得「這些都不是錯誤啊,回 0 很合理」。是很合理,但代價是三個月後的我,看紀錄看不出差別。
這就是我要修的東西。不是修那支腳本(它照樣跑),是補上它沒留下的那一層:排程有沒有被叫到、來源有沒有回應、這一輪有沒有新東西,是三件事,不該共用一個「成功」。
先講三個我後面一直會用的詞。一輪=排程被叫到一次、該處理的那一批工作。候選=來源給我的一筆一筆東西(一支影片、一則訊息),還沒決定要不要處理。選料=從候選裡挑出這一輪真的要送下去的那幾筆,挑掉的也要寫原因。
我把一輪要留的紀錄拆成三層:
| 層次 | 回答什麼 | 最少要留什麼 |
|---|---|---|
| 觸發 | 原本排定何時?程式何時真的進來? | 排定時刻、觀察到的時刻、觸發種類 |
| 來源 | 有沒有去問?來源回了嗎?回幾筆? | 狀態、候選數、失敗類別 |
| 選料 | 哪些候選屬於這一輪又通過規則?哪些被排除、為什麼? | 接受清單、逐筆排除原因、規則版本、最後決定 |
三層不能互相代表。觸發層有紀錄,只說程式被叫到了;來源回空集合跟來源根本回不了,是兩種完全不同的狀況;要到選料層,才輪得到下游 AI 出場。
順帶說一個我原本以為可以省的欄位:排定時刻和真的進來的時刻,我一度想只存一個。後來看到 Apple 自己的 launchd 文件寫,Mac 睡著時錯過的工作可以在喚醒後才啟動,關機期間錯過的要等下一個排定時間;GitHub Actions 的文件也提醒,排程在高負載時可能延遲、甚至被直接捨棄。
那就是兩件事了。只存一個「07:30」,之後我分不出準時、遲到,還是根本沒跑。
我原本也只想給一輪一個編號。逼我加第二個的是這件事:一次排定,不保證只進來一次。
Kubernetes 的 CronJob 文件講得很直白:排程是近似的,某些情況可能建立兩個 Job,也可能一個都沒建,所以工作本身要能處理重複。Google Cloud Scheduler 是至少一次投遞,同一個排定工作可能執行多次,官方建議用工作名稱加原定排程時間辨認重複。
所以分成兩個:
一輪識別碼代表「邏輯上的同一輪」。它由產線代號、排定時刻、時間窗起訖、選料規則版本組成一份固定 JSON,算 SHA-256,取十六進位輸出的前 32 個字元。同樣的輸入重算一定得到同一個值;我在測試裡改排定時刻、改時間窗起點、改規則版本,三次都跳出不同的值。
這次進來的編號由一輪識別碼加上一個正整數序號推導。這裡要誠實:序號是呼叫端自己給的,我沒有替多個程序配置或鎖定序號,所以能證明的只有「同一輪加同一個序號,一定重算出同一個編號」,不是跨程序不會撞號。
實驗裡有一路就在演這件事:同一個排定事件送兩次,一輪識別碼相同,這次進來的編號不同,第二次在還沒去問來源之前就結束了。
時間窗用「前含後不含」:起點算進去,終點不算。
下面這兩個窗是固定測試資料,時段刻意取得跟我真實排程一樣方便對照,但出現的日期與候選都不是那條線的紀錄:
A:[09-07 22:00, 09-08 07:30)
B:[09-08 07:30, 09-08 22:00)
邊界那一刻不屬於 A、屬於 B。兩窗不重疊,同一瞬間不會被兩輪同時處理。
這個寫法其實有人早就解過。RFC 5545 那份行事曆規格,就把開始時間當包含、結束時間當不包含,相鄰的區間可以共用邊界,又不會讓同一瞬間屬於兩邊。第 10 天處理影音定位時我用過同一種判定,今天只是把對象從時間碼換成「一輪的資料範圍」。
然後我故意把 B 的起點往前挪一秒。兩個窗真的重疊了,工作流在還沒去問來源之前就以「時間窗重疊」被拒絕,來源與 AI 呼叫都是 0。這是我特別想驗的:擋,要擋在花錢之前。
落在時間窗內,只代表這筆候選屬於這一輪的範圍。它還要通過一個帶版本號的選料規則。
歸窗看的是候選自己的發生時間,不是我抓到它的時間。
而且結果不能只存「候選幾筆」。每一筆都要留下識別、去留、原因。不然日後只看到「2 筆」,還是不知道發生了什麼。
有候選但沒有一筆合格那一路,固定回兩筆:一筆發生在時間窗起點之前,以「不在時間窗內」排除;另一筆在窗內,但自己不符合規則,以「規則排除」排除。接受清單空的,決定是「沒有合格輸入」,去問了來源一次,AI 呼叫 0 次。
成功那一路也是兩筆,一筆進接受清單、一筆被規則排除。
順帶一個當初沒想到的效果:規則版本換掉,同一個時間窗也會算出不同的一輪識別碼。因為規則版本本來就是一輪識別碼的組成部分。換了選料規則,本來就不該算成同一輪。這是撿到的,不是設計出來的。
看最後一欄就好:只有最後一路呼叫了 AI。
| 情境 | 來源狀態 | 候選數 | 最後決定 | 問了來源幾次/呼叫 AI 幾次 |
|---|---|---|---|---|
| 來源回空集合 | 有回應 | 0 | 沒有新工作(來源為空) | 1/0 |
| 時間窗重疊 | 沒去問 | 不適用 | 拒絕(時間窗重疊) | 0/0 |
| 重複觸發 | 沒去問 | 不適用 | 忽略(重複觸發) | 0/0 |
| 有候選但全被排除 | 有回應 | 2 | 沒有新工作(沒有合格輸入) | 1/0 |
| 來源回不了 | 失敗 | 不適用 | 停止(來源無法回應) | 1/0 |
| 選出合格新資料 | 有回應 | 2 | 可以開始 | 1/1 |
五個 0,一個 1。
下游那個 AI 的入口只有一個條件:決定是「可以開始」,而且接受清單不是空的。時間窗重疊和重複觸發連來源都不去問;空集合、全被排除、來源失敗這三路雖然碰了來源,也不會把空白或錯誤的資料送進模型。
這裡用的是確定性的假呼叫端,我只驗「AI 有沒有被呼叫、收到哪些候選」。沒有呼叫任何外部模型,也沒有評估摘要品質,那不是今天的題目。
同一張表裡,有兩種「沒有新工作」和一種「停止」,這是我覺得今天最值得記住的地方。
來源為空:來源正常回應,候選數就是 0。今天真的沒有新東西。
沒有合格輸入:來源回了候選,但每一筆都被選料規則排除。有東西進來,只是都不該處理。
兩者都以「沒有新工作」結束,也都不呼叫 AI。但原因得分開存——不然我永遠不知道是來源乾涸了,還是我的規則太嚴。
至於來源無法回應,狀態是失敗,決定是停止。如果把它也記成零候選,我就分不出「今天真的沒有新東西」和「系統根本沒拿到資料」。這兩個看起來都是一片空白,但一個該睡覺,一個該去修。
HTTP 早就把這個責任分開了。RFC 9110 裡,204 是請求成功但沒有內容,503 是服務目前無法處理請求。都沒拿到內容,但語意完全不同。我用的是自己的狀態名,不是把 shell 的退出碼重新命名成 HTTP 狀態碼。
先給一組對照,看程式欄位就不用猜:noop 是沒有新工作、ready 是可以開始、rejected 是拒絕、ignored 是忽略、stopped 是停止;lane_id 是哪一條產線、accepted_refs 是接受清單。
這是「來源回空集合」那一路保存下來的完整內容:
{
"schema_version": "trigger-contract.v1",
"lane_id": "content-intake",
"trigger": {
"kind": "scheduled",
"scheduled_for": "2026-09-08T07:30:00+08:00",
"observed_at": "2026-09-08T07:30:05+08:00"
},
"window": {
"start_inclusive": "2026-09-07T22:00:00+08:00",
"end_exclusive": "2026-09-08T07:30:00+08:00"
},
"selection_policy_version": "policy-v1",
"run_id": "run_a860db97e33115dfb9030fc9986dd88c",
"attempt_id": "attempt_9b92c6df2d1d4da4125b6de15d118f7f",
"source_observation": { "status": "responded", "candidate_count": 0 },
"selection": { "accepted_refs": [], "rejected": [] },
"decision": { "status": "noop", "reason": "source_empty" }
}
時間全部帶時區,這是 RFC 3339 的格式。只存「07:30」這種沒有日期也沒有時區的字串,跨程序就重算不出來。
裡面沒有 AI 呼叫計數。那是外層稽核記的東西,屬於測試觀察,不是這份契約的欄位。這兩件事我刻意不放在一起。
六路的測試檔這次跑 16 條全過,含六種結果、識別碼重算、時間窗邊界與重疊拒絕,以及保存的稽核能被程式完整重算。
寫到這裡我才發現,我以為自己在解一個很土的個人問題,其實它有一整排前例。
Apache Airflow 把一輪的資料區間、邏輯日期、實際開始時間分開存。對按時間處理資料的工作來說,邏輯日期代表資料區間的起點,排程器通常等區間結束後才建立那一輪,所以「處理哪一段資料」跟「程式什麼時候真的跑」本來就不是同一個時間。
AWS 的 EventBridge Scheduler 有「彈性時間窗」,那是允許在哪段時間內送達;跟我這裡的選料時間窗名字很像,責任完全不同。一個管投遞,一個管選料。
CloudEvents 規格要求事件的 id 在它的 source 範圍內唯一,消費者用 source 加 id 的組合辨認重複事件。這個拆法剛好可以拿來檢查我有沒有把「來源範圍」「事件識別」「時間」混成一個欄位。
這些都是相鄰機制,用來說明問題的形狀。我沒有用 Airflow、沒有用 EventBridge、也沒有發 CloudEvent;雲端排程器的重複與延遲行為,也不能拿來當我本機 launchd 的實測。我那兩條真實排程的健康度,22:49 那一次查核也只能說明查核當下,推不出其他日期。
今天沒有做出什麼很炫的東西。做出來的是一張單子:這一輪原本排定何時、實際何時進來、時間窗到哪、來源回了什麼、哪幾筆被挑走、哪幾筆為什麼被丟掉、最後決定是什麼。
它的價值在明天。
第 17 天要問的是:這一輪跑到一半,程序掛了,重新啟動的時候該重試、該接續,還是該停?
那時候要判斷的依據,就是今天這份單子。所以我今天不寫恢復策略——沒有可靠的一輪紀錄,恢復只能靠猜。而猜錯的代價,可能是重複處理,也可能是永遠漏掉。